[wrangler] Allow containers to be attached to Durable Objects via exports - #15026
[wrangler] Allow containers to be attached to Durable Objects via exports#15026petebacondarwin wants to merge 7 commits into
exports#15026Conversation
…ports`
A container can now be linked to its Durable Object from the export side,
using a new `container` field that names an entry in the `containers`
array:
"containers": [{ "name": "my-container", "image": "./Dockerfile" }],
"exports": {
"MyContainerDO": {
"type": "durable-object",
"storage": "sqlite",
"container": "my-container"
}
}
As a result `containers[].class_name` is now optional — a container that
is referenced this way only needs a `name`. This decouples container
configuration from the Durable Object class, which is a prerequisite for
configuring containers as standalone resources.
The link is resolved by a shared helper in `@cloudflare/workers-utils`,
used by wrangler's container normalization, the Vite plugin's dev/preview
container builds, and `unstable_getMiniflareWorkerOptions`. Upload
metadata sends both directions as configured (`containers[].name` and
`exports[].container`) so that the API can do its own resolution;
wrangler only pre-resolves for local dev and Cloudchamber app creation.
Cross-field validation now reports dangling `container` references, two
exports claiming the same container, the two directions disagreeing, a
container linked to no Durable Object, `class_name` with no live export,
and duplicate container names.
…exports` Deploys a Worker whose container is linked only from the Durable Object's `exports` entry, then asserts `ctx.container` is present on the deployed Durable Object. That is only true when the API resolved the link from the export side, so it covers the server half of the feature end to end. A second case moves the same link back to `containers[].class_name` and re-deploys, verifying the container stays attached and that the two directions really are interchangeable. The image is pushed once up front under a known tag so that teardown can delete it deterministically, and so neither deploy has to build it. The block is skipped when Docker is unavailable.
🦋 Changeset detectedLatest commit: 676980f The changes in this PR will be included in the next version bump. This PR includes changesets to release 5 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Codeowners approval required for this PR:
Show detailed file reviewers
|
|
✅ All changesets look good |
|
Here I've completed a thorough review. The changes are well-structured, the validation is comprehensive, edge cases (tombstones, inheritance, duplicate names, both-direction consistency) are handled, and test coverage is extensive. I found no logic bugs, security issues, backward-compat violations, or incorrect API behavior. LGTM |
@cloudflare/autoconfig
@cloudflare/build-output-utils
@cloudflare/config
create-cloudflare
@cloudflare/deploy-helpers
@cloudflare/kv-asset-handler
miniflare
@cloudflare/pages-functions
@cloudflare/pages-shared
@cloudflare/unenv-preset
@cloudflare/vite-plugin
@cloudflare/vitest-pool-workers
@cloudflare/workers-auth
@cloudflare/workers-editor-shared
@cloudflare/workers-utils
wrangler
commit: |
`versions upload` sends `containers[].name` alongside `class_name` now, so this assertion needs the derived `<worker>-<class>` name. The container in this test has no explicit `name`, which is exactly the case where validation fills one in.
The round-trip consistency check only looked one way: it compared a
container's `class_name` against the `container` field of that same class's
export. It never asked whether some *other* export had claimed the
container, so this contradictory config was accepted silently:
"containers": [{ "name": "x", "class_name": "Y", "image": "..." }],
"exports": {
"Y": { "type": "durable-object", "storage": "sqlite" },
"MyDO": { "type": "durable-object", "storage": "sqlite", "container": "x" }
}
`exports.Y` names no container, so the existing comparison had nothing to
compare against. Wrangler would then resolve the container to `Y` locally
while the upload metadata let the API resolve it to `MyDO`.
When more than one export claims the container, the existing
duplicate-claim error already covers it, and singling one of them out here
would make the message depend on the order of the keys in `exports`.
Reported by Devin Review on #15026.
Two containers sharing a `class_name` was accepted but could never work:
workerd attaches a single container per Durable Object namespace, and in
local dev `getDevContainerImageName` derives the image tag from the class
name alone, so both containers build into the same tag and one silently
overwrites the other. The vite plugin's map keyed by `image_tag` and
miniflare's `Map<className, { container? }>` each drop one too.
The cross-field validation added in this branch made that latent problem
incoherent rather than fixing it. The same config was accepted, rejected as
a link "disagreement", or rejected as a duplicate derived name depending on
incidental details:
a, b both `class_name: MyDO`, no export names either -> accepted
a, b both `class_name: MyDO`, `exports.MyDO.container: a` -> "disagreement"
two unnamed containers on MyDO -> duplicate name "worker-mydo"
All three now report the same root cause. The round-trip checks are skipped
for an over-subscribed class so they no longer blame a container for a
sibling owning the class's `container` field.
The claim that a Durable Object could be backed by several containers was
introduced earlier in this branch, in a doc comment and a test; both are
corrected. `cloudchamber/apply` had a two-application test that copied
`class_name` via a spread, so the second app now gets its own class.
Reported by Devin Review on #15026.
`exports` is inherited by named environments but `containers` is not, so the idiomatic multi-environment layout declares `exports` once at the top level and repeats `containers` in each environment. The top level pass then saw an `exports[Class].container` reference with no containers to match it against and reported it as dangling. Because that pass writes to the root diagnostics, the error surfaced even when a named environment was selected, so the config could not be loaded for any environment. The existing guard only covered the mirror case, where a named environment inherits `exports` without redeclaring `containers`. Generalise it to skip the cross-check whenever the containers are declared at a different environment level, in either direction.
The "skips containers" test asserted only that an all-unlinked container list produces an empty array, which left it unclear whether the empty array or `undefined` was intended, and did not actually prove that a linked container survives alongside an unlinked one. Assert the mixed case instead, and keep the empty result as a separate test that says why both shapes are equivalent: they mean the same thing and both call sites iterate `options ?? []`. Document the empty array on `getContainerOptions`, whose contract previously mentioned only `undefined`.
Fixes DEVX-2628.
Containers can now be attached to a Durable Object from the export side, using a new
containerfield on adurable-objectexport that names an entry in thecontainersarray:{ "name": "my-worker", "main": "worker.js", "compatibility_date": "2026-07-01", "containers": [ { "name": "my-container", "image": "./Dockerfile", "max_instances": 1 } ], "exports": { "MyContainerDO": { "type": "durable-object", "storage": "sqlite", "container": "my-container" } } }As a result
containers[].class_nameis now optional — a container referenced this way only needs aname. This decouples container configuration from the Durable Object class, which is a prerequisite for configuring containers as standalone resources.The existing
containers[].class_namedirection keeps working, and either direction may be used, but a Durable Object and its container must reference each other consistently when both are set.How the link is resolved
Wrangler sends both directions in the upload metadata exactly as configured (
containers[].nameandexports[Class].container) and the API does its own resolution. Wrangler only pre-resolves the class name locally where it genuinely needs it — local dev image tagging and Cloudchamber app creation — via a single shared helper (resolveContainerClassNamein@cloudflare/workers-utils) used by wrangler, the Vite plugin, andunstable_getMiniflareWorkerOptions.New validation
containeris only valid on livedurable-objectexports (createdandexpecting-transfer) and requiresstorage: "sqlite". Cross-field validation now reports:containerreference naming a container that isn't definedclass_namepointing at a class with no livedurable-objectexport (only when theexportsflow is in use — the legacymigrationsflow keeps its silent-ignore behaviour)name(behaviour change: previously the duplicate was silently tolerated)Also included
@cloudflare/config:containeradded toexports.durableObject()for the experimentalcloudflare.config.tsformat.fixtures/container-appnow uses the new shape (wrangler.registry.jsoncretains theclass_name+migrationsequivalent for comparison).validateContainerAppwhere thenametype check tested the wholecontainersarray rather than each entry, so it never fired.partitionExportsagainst malformed/unknown export types, which previously threw rather than letting validation report them.Test coverage added:
packages/workers-utils/tests/config/containers.test.ts— unit tests for the shared resolver.packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts— all six validation rules, pluscontainershape andlegacy-kvrejection.packages/wrangler/src/__tests__/containers/{config,deploy,schema}.test.tsandcreate-worker-upload-form/metadata.test.ts— resolution, deploy, and upload-metadata coverage.packages/wrangler/src/__tests__/dev.test.tsandpackages/vite-plugin-cloudflare/src/__tests__/containers.spec.ts— local dev paths.packages/config/src/__tests__/{convert,schema}.test.ts.packages/wrangler/e2e/durable-objects-exports.test.ts— a livewrangler deploye2e that assertsctx.containeris present on the deployed Durable Object (so it covers the API's half of the resolution end to end), plus a case that moves the link toclass_nameand re-deploys to confirm the two directions are interchangeable. Skipped when Docker is unavailable.A picture of a cute animal (not mandatory, but encouraged)
Note
This is a contribution from an AI agent: OpenCode, claude-opus-5.